<?php

namespace Sjj\Eloquent\Concerns;

use Exception;
use LogicException;
use DateTimeInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Support\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Collection as BaseCollection;
use Illuminate\Database\Eloquent\JsonEncodingException;

trait HasAttributes
{
	/**
	 * The model's attributes.
	 *
	 * @var array
	 */
	protected $attributes = [];

	/**
	 * The attributes that should be cast to native types.
	 *
	 * @var array
	 */
	protected $casts = [];

	/**
	 * The attributes that should be mutated to dates.
	 *
	 * @var array
	 */
	protected $dates = [];

	/**
	 * The storage format of the model's date columns.
	 *
	 * @var string
	 */
	protected $dateFormat;

	/**
	 * The accessors to append to the model's array form.
	 *
	 * @var array
	 */
	protected $appends = [];

	/**
	 * Indicates whether attributes are snake cased on arrays.
	 *
	 * @var bool
	 */
	public static $snakeAttributes = true;

	/**
	 * The cache of the mutated attributes for each class.
	 *
	 * @var array
	 */
	protected static $mutatorCache = [];

	/**
	 * Convert the model's attributes to an array.
	 *
	 * @return array
	 * @throws Exception
	 */
	public function attributesToArray()
	{
		// If an attribute is a date, we will cast it to a string after converting it
		// to a DateTime / Carbon instance. This is so we will get some consistent
		// formatting while accessing attributes vs. arraying / JSONing a model.
		$attributes = $this->addDateAttributesToArray(
			$attributes = $this->getArrayableAttributes()
		);

		$attributes = $this->addMutatedAttributesToArray(
			$attributes, $mutatedAttributes = $this->getMutatedAttributes()
		);

		// Next we will handle any casts that have been setup for this model and cast
		// the values to their appropriate type. If the attribute has a mutator we
		// will not perform the cast on those attributes to avoid any confusion.
		$attributes = $this->addCastAttributesToArray(
			$attributes, $mutatedAttributes
		);

		// Here we will grab all of the appended, calculated attributes to this model
		// as these attributes are not really in the attributes array, but are run
		// when we need to array or JSON the model for convenience to the coder.
		foreach ($this->getArrayableAppends() as $key) {
			$attributes[$key] = $this->mutateAttributeForArray($key, null);
		}

		return $attributes;
	}

	/**
	 * Add the date attributes to the attributes array.
	 *
	 * @param array $attributes
	 * @return array
	 * @throws Exception
	 */
	protected function addDateAttributesToArray(array $attributes)
	{
		foreach ($this->getDates() as $key) {
			if (!isset($attributes[$key])) {
				continue;
			}

			$attributes[$key] = $this->serializeDate(
				$this->asDateTime($attributes[$key])
			);
		}

		return $attributes;
	}

	/**
	 * Add the mutated attributes to the attributes array.
	 *
	 * @param array $attributes
	 * @param array $mutatedAttributes
	 * @return array
	 */
	protected function addMutatedAttributesToArray(array $attributes, array $mutatedAttributes)
	{
		foreach ($mutatedAttributes as $key) {
			// We want to spin through all the mutated attributes for this model and call
			// the mutator for the attribute. We cache off every mutated attributes so
			// we don't have to constantly check on attributes that actually change.
			if (!array_key_exists($key, $attributes)) {
				continue;
			}

			// Next, we will call the mutator for this attribute so that we can get these
			// mutated attribute's actual values. After we finish mutating each of the
			// attributes we will return this final array of the mutated attributes.
			$attributes[$key] = $this->mutateAttributeForArray(
				$key, $attributes[$key]
			);
		}

		return $attributes;
	}

	/**
	 * Add the casted attributes to the attributes array.
	 *
	 * @param array $attributes
	 * @param array $mutatedAttributes
	 * @return array
	 * @throws Exception
	 */
	protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes)
	{
		foreach ($this->getCasts() as $key => $value) {
			if (!array_key_exists($key, $attributes) || in_array($key, $mutatedAttributes)) {
				continue;
			}

			// Here we will cast the attribute. Then, if the cast is a date or datetime cast
			// then we will serialize the date for the array. This will convert the dates
			// to strings based on the date format specified for these Eloquent models.
			$attributes[$key] = $this->castAttribute($key, $attributes[$key]);

			// If the attribute cast was a date or a datetime, we will serialize the date as
			// a string. This allows the developers to customize how dates are serialized
			// into an array without affecting how they are persisted into the storage.
			if ($attributes[$key] &&
				($value === 'date' || $value === 'datetime')) {
				$attributes[$key] = $this->serializeDate($attributes[$key]);
			}

			if ($attributes[$key] && $this->isCustomDateTimeCast($value)) {
				$attributes[$key] = $attributes[$key]->format(explode(':', $value, 2)[1]);
			}
		}

		return $attributes;
	}

	/**
	 * Get an attribute array of all arrayable attributes.
	 *
	 * @return array
	 */
	protected function getArrayableAttributes()
	{
		return $this->getArrayableItems($this->attributes);
	}

	/**
	 * Get all of the appendable values that are arrayable.
	 *
	 * @return array
	 */
	protected function getArrayableAppends()
	{
		if (!count($this->appends)) {
			return [];
		}

		return $this->getArrayableItems(
			array_combine($this->appends, $this->appends)
		);
	}

	/**
	 * Get the model's relationships in array form.
	 *
	 * @return array
	 */
	public function relationsToArray()
	{
		$attributes = [];

		foreach ($this->getArrayableRelations() as $key => $value) {
			// If the values implements the Arrayable interface we can just call this
			// toArray method on the instances which will convert both models and
			// collections to their proper array form and we'll set the values.
			if ($value instanceof Arrayable) {
				$relation = $value->toArray();
			}

			// If the value is null, we'll still go ahead and set it in this list of
			// attributes since null is used to represent empty relationships if
			// if it a has one or belongs to type relationships on the models.
			elseif (is_null($value)) {
				$relation = $value;
			}

			// If the relationships snake-casing is enabled, we will snake case this
			// key so that the relation attribute is snake cased in this returned
			// array to the developers, making this consistent with attributes.
			if (static::$snakeAttributes) {
				$key = Str::snake($key);
			}

			// If the relation value has been set, we will set it on this attributes
			// list for returning. If it was not arrayable or null, we'll not set
			// the value on the array because it is some type of invalid value.
			if (isset($relation) || is_null($value)) {
				$attributes[$key] = $relation;
			}

			unset($relation);
		}

		return $attributes;
	}

	/**
	 * Get an attribute array of all arrayable relations.
	 *
	 * @return array
	 */
	protected function getArrayableRelations()
	{
		return $this->getArrayableItems($this->relations);
	}

	/**
	 * Get an attribute array of all arrayable values.
	 *
	 * @param array $values
	 * @return array
	 */
	protected function getArrayableItems(array $values)
	{
		if (count($this->getVisible()) > 0) {
			$values = array_intersect_key($values, array_flip($this->getVisible()));
		}

		if (count($this->getHidden()) > 0) {
			$values = array_diff_key($values, array_flip($this->getHidden()));
		}

		return $values;
	}

	/**
	 * Get an attribute from the model.
	 *
	 * @param string $key
	 * @return mixed
	 * @throws Exception
	 */
	public function getAttribute($key)
	{
		if (!$key) {
			return null;
		}

		// If the attribute exists in the attribute array or has a "get" mutator we will
		// get the attribute's value. Otherwise, we will proceed as if the developers
		// are asking for a relationship's value. This covers both types of values.
		if (array_key_exists($key, $this->attributes) || $this->hasGetMutator($key)) {
			return $this->getAttributeValue($key);
		}

		// Here we will determine if the model base class itself contains this given key
		// since we don't want to treat any of those methods as relationships because
		// they are all intended as helper methods and none of these are relations.
		if (method_exists(self::class, $key)) {
			return null;
		}

		return $this->getRelationValue($key);
	}

	/**
	 * Get a plain attribute (not a relationship).
	 *
	 * @param string $key
	 * @return mixed
	 * @throws Exception
	 */
	public function getAttributeValue($key)
	{
		$value = $this->getAttributeFromArray($key);

		// If the attribute has a get mutator, we will call that then return what
		// it returns as the value, which is useful for transforming values on
		// retrieval from the model to a form that is more useful for usage.
		if ($this->hasGetMutator($key)) {
			return $this->mutateAttribute($key, $value);
		}

		// If the attribute exists within the cast array, we will convert it to
		// an appropriate native PHP type dependant upon the associated value
		// given with the key in the pair. Dayle made this comment line up.
		if ($this->hasCast($key)) {
			return $this->castAttribute($key, $value);
		}

		// If the attribute is listed as a date, we will convert it to a DateTime
		// instance on retrieval, which makes it quite convenient to work with
		// date fields without having to create a mutator for each property.
		if (in_array($key, $this->getDates()) && !is_null($value)) {
			return $this->asDateTime($value);
		}

		return $value;
	}

	/**
	 * Get an attribute from the $attributes array.
	 *
	 * @param string $key
	 * @return mixed
	 */
	protected function getAttributeFromArray($key)
	{
		if (isset($this->attributes[$key])) {
			return $this->attributes[$key];
		}
		return null;
	}

	/**
	 * Get a relationship.
	 *
	 * @param string $key
	 * @return mixed
	 */
	public function getRelationValue($key)
	{
		// If the key already exists in the relationships array, it just means the
		// relationship has already been loaded, so we'll just return it out of
		// here because there is no need to query within the relations twice.
		if ($this->relationLoaded($key)) {
			return $this->relations[$key];
		}
		return null;
	}

	/**
	 * Get a relationship value from a method.
	 *
	 * @param string $method
	 * @return mixed
	 *
	 * @throws \LogicException
	 */
	protected function getRelationshipFromMethod($method)
	{
		$relation = $this->$method();

		if (!$relation instanceof Relation) {
			throw new LogicException(sprintf(
				'%s::%s must return a relationship instance.', static::class, $method
			));
		}

		return tap($relation->getResults(), function ($results) use ($method) {
			$this->setRelation($method, $results);
		});
	}

	/**
	 * Determine if a get mutator exists for an attribute.
	 *
	 * @param string $key
	 * @return bool
	 */
	public function hasGetMutator($key)
	{
		return method_exists($this, 'get' . Str::studly($key) . 'Attribute');
	}

	/**
	 * Get the value of an attribute using its mutator.
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return mixed
	 */
	protected function mutateAttribute($key, $value)
	{
		return $this->{'get' . Str::studly($key) . 'Attribute'}($value);
	}

	/**
	 * Get the value of an attribute using its mutator for array conversion.
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return mixed
	 */
	protected function mutateAttributeForArray($key, $value)
	{
		$value = $this->mutateAttribute($key, $value);

		return $value instanceof Arrayable ? $value->toArray() : $value;
	}

	/**
	 * Cast an attribute to a native PHP type.
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return mixed
	 * @throws Exception
	 */
	protected function castAttribute($key, $value)
	{
		if (is_null($value)) {
			return $value;
		}

		switch ($this->getCastType($key)) {
			case 'int':
			case 'integer':
				return (int)$value;
			case 'real':
			case 'float':
			case 'double':
				return (float)$value;
			case 'string':
				return (string)$value;
			case 'bool':
			case 'boolean':
				return (bool)$value;
			case 'object':
				return $this->fromJson($value, true);
			case 'array':
			case 'json':
				return $this->fromJson($value);
			case 'collection':
				return new BaseCollection($this->fromJson($value));
			case 'date':
				return $this->asDate($value);
			case 'datetime':
			case 'custom_datetime':
				return $this->asDateTime($value);
			case 'timestamp':
				return $this->asTimestamp($value);
			default:
				return $value;
		}
	}

	/**
	 * Get the type of cast for a model attribute.
	 *
	 * @param string $key
	 * @return string
	 */
	protected function getCastType($key)
	{
		if ($this->isCustomDateTimeCast($this->getCasts()[$key])) {
			return 'custom_datetime';
		}

		return trim(strtolower($this->getCasts()[$key]));
	}

	/**
	 * Determine if the cast type is a custom date time cast.
	 *
	 * @param string $cast
	 * @return bool
	 */
	protected function isCustomDateTimeCast($cast)
	{
		return strncmp($cast, 'date:', 5) === 0 ||
			strncmp($cast, 'datetime:', 9) === 0;
	}

	/**
	 * Set a given attribute on the model.
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return mixed
	 */
	public function setAttribute($key, $value)
	{
		// First we will check for the presence of a mutator for the set operation
		// which simply lets the developers tweak the attribute as it is set on
		// the model, such as "json_encoding" an listing of data for storage.
		if ($this->hasSetMutator($key)) {
			return $this->setMutatedAttributeValue($key, $value);
		}

		// If an attribute is listed as a "date", we'll convert it from a DateTime
		// instance into a form proper for storage on the database tables using
		// the connection grammar's date format. We will auto set the values.
		elseif ($value && $this->isDateAttribute($key)) {
			$value = $this->fromDateTime($value);
		}

		if ($this->isJsonCastable($key) && !is_null($value)) {
			$value = $this->castAttributeAsJson($key, $value);
		}

		// If this attribute contains a JSON ->, we'll set the proper value in the
		// attribute's underlying array. This takes care of properly nesting an
		// attribute in the array's value in the case of deeply nested items.
		if (Str::contains($key, '->')) {
			return $this->fillJsonAttribute($key, $value);
		}

		$this->attributes[$key] = $value;

		return $this;
	}

	/**
	 * Determine if a set mutator exists for an attribute.
	 *
	 * @param string $key
	 * @return bool
	 */
	public function hasSetMutator($key)
	{
		return method_exists($this, 'set' . Str::studly($key) . 'Attribute');
	}

	/**
	 * Set the value of an attribute using its mutator.
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return mixed
	 */
	protected function setMutatedAttributeValue($key, $value)
	{
		return $this->{'set' . Str::studly($key) . 'Attribute'}($value);
	}

	/**
	 * Determine if the given attribute is a date or date castable.
	 *
	 * @param string $key
	 * @return bool
	 */
	protected function isDateAttribute($key)
	{
		return in_array($key, $this->getDates()) || $this->isDateCastable($key);
	}

	/**
	 * Set a given JSON attribute on the model.
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return $this
	 */
	public function fillJsonAttribute($key, $value)
	{
		list($key, $path) = explode('->', $key, 2);

		$this->attributes[$key] = $this->asJson($this->getArrayAttributeWithValue(
			$path, $key, $value
		));

		return $this;
	}

	/**
	 * Get an array attribute with the given key and value set.
	 *
	 * @param string $path
	 * @param string $key
	 * @param mixed $value
	 * @return $this
	 */
	protected function getArrayAttributeWithValue($path, $key, $value)
	{
		return tap($this->getArrayAttributeByKey($key), function (&$array) use ($path, $value) {
			Arr::set($array, str_replace('->', '.', $path), $value);
		});
	}

	/**
	 * Get an array attribute or return an empty array if it is not set.
	 *
	 * @param string $key
	 * @return array
	 */
	protected function getArrayAttributeByKey($key)
	{
		return isset($this->attributes[$key]) ?
			$this->fromJson($this->attributes[$key]) : [];
	}

	/**
	 * Cast the given attribute to JSON.
	 *
	 * @param string $key
	 * @param mixed $value
	 * @return string
	 */
	protected function castAttributeAsJson($key, $value)
	{
		$value = $this->asJson($value);

		if ($value === false) {
			throw JsonEncodingException::forAttribute(
				$this, $key, json_last_error_msg()
			);
		}

		return $value;
	}

	/**
	 * Encode the given value as JSON.
	 *
	 * @param mixed $value
	 * @return string
	 */
	protected function asJson($value)
	{
		return json_encode($value);
	}

	/**
	 * Decode the given JSON back into an array or object.
	 *
	 * @param string $value
	 * @param bool $asObject
	 * @return mixed
	 */
	public function fromJson($value, $asObject = false)
	{
		return json_decode($value, !$asObject);
	}

	/**
	 * Return a timestamp as DateTime object with time set to 00:00:00.
	 *
	 * @param mixed $value
	 * @return \Carbon\Carbon
	 * @throws Exception
	 */
	protected function asDate($value)
	{
		return $this->asDateTime($value)->startOfDay();
	}

	/**
	 * Return a timestamp as DateTime object.
	 *
	 * @param mixed $value
	 * @return \Carbon\Carbon
	 */
	protected function asDateTime($value)
	{
		// If this value is already a Carbon instance, we shall just return it as is.
		// This prevents us having to re-instantiate a Carbon instance when we know
		// it already is one, which wouldn't be fulfilled by the DateTime check.
		if ($value instanceof Carbon) {
			return $value;
		}

		// If the value is already a DateTime instance, we will just skip the rest of
		// these checks since they will be a waste of time, and hinder performance
		// when checking the field. We will just return the DateTime right away.
		if ($value instanceof DateTimeInterface) {
			return new Carbon(
				$value->format('Y-m-d H:i:s.u'), $value->getTimezone()
			);
		}

		// If this value is an integer, we will assume it is a UNIX timestamp's value
		// and format a Carbon object from this timestamp. This allows flexibility
		// when defining your date fields as they might be UNIX timestamps here.
		if (is_numeric($value)) {
			return Carbon::createFromTimestamp($value);
		}

		// If the value is in simply year, month, day format, we will instantiate the
		// Carbon instances from that format. Again, this provides for simple date
		// fields on the database, while still supporting Carbonized conversion.
		if ($this->isStandardDateFormat($value)) {
			return Carbon::createFromFormat('Y-m-d', $value)->startOfDay();
		}

		// Finally, we will just assume this date is in the format used by default on
		// the database connection and use that format to create the Carbon object
		// that is returned back out to the developers after we convert it here.
		return Carbon::createFromFormat(
			str_replace('.v', '.u', $this->getDateFormat()), $value
		);
	}

	/**
	 * Determine if the given value is a standard date format.
	 *
	 * @param string $value
	 * @return bool
	 */
	protected function isStandardDateFormat($value)
	{
		return preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value);
	}

	/**
	 * Convert a DateTime to a storable string.
	 *
	 * @param \DateTime|int $value
	 * @return string
	 */
	public function fromDateTime($value)
	{
		return empty($value) ? $value : $this->asDateTime($value)->format(
			$this->getDateFormat()
		);
	}

	/**
	 * Return a timestamp as unix timestamp.
	 *
	 * @param mixed $value
	 * @return int
	 * @throws Exception
	 */
	protected function asTimestamp($value)
	{
		return $this->asDateTime($value)->getTimestamp();
	}

	/**
	 * Prepare a date for array / JSON serialization.
	 *
	 * @param \DateTimeInterface $date
	 * @return string
	 */
	protected function serializeDate(DateTimeInterface $date)
	{
		return $date->format($this->getDateFormat());
	}

	/**
	 * Get the attributes that should be converted to dates.
	 *
	 * @return array
	 */
	public function getDates()
	{
		return $this->dates;
	}

	/**
	 * Get the format for database stored dates.
	 *
	 * @return string
	 */
	public function getDateFormat()
	{
		return $this->dateFormat;
	}

	/**
	 * Set the date format used by the model.
	 *
	 * @param string $format
	 * @return $this
	 */
	public function setDateFormat($format)
	{
		$this->dateFormat = $format;

		return $this;
	}

	/**
	 * Determine whether an attribute should be cast to a native type.
	 *
	 * @param string $key
	 * @param array|string|null $types
	 * @return bool
	 */
	public function hasCast($key, $types = null)
	{
		if (array_key_exists($key, $this->getCasts())) {
			return $types ? in_array($this->getCastType($key), (array)$types, true) : true;
		}

		return false;
	}

	/**
	 * Get the casts array.
	 *
	 * @return array
	 */
	public function getCasts()
	{
		return $this->casts;
	}

	/**
	 * Determine whether a value is Date / DateTime castable for inbound manipulation.
	 *
	 * @param string $key
	 * @return bool
	 */
	protected function isDateCastable($key)
	{
		return $this->hasCast($key, ['date', 'datetime']);
	}

	/**
	 * Determine whether a value is JSON castable for inbound manipulation.
	 *
	 * @param string $key
	 * @return bool
	 */
	protected function isJsonCastable($key)
	{
		return $this->hasCast($key, ['array', 'json', 'object', 'collection']);
	}

	/**
	 * Get all of the current attributes on the model.
	 *
	 * @return array
	 */
	public function getAttributes()
	{
		return $this->attributes;
	}

	/**
	 * Get a subset of the model's attributes.
	 *
	 * @param array|mixed $attributes
	 * @return array
	 * @throws Exception
	 */
	public function only($attributes)
	{
		$results = [];

		foreach (is_array($attributes) ? $attributes : func_get_args() as $attribute) {
			$results[$attribute] = $this->getAttribute($attribute);
		}

		return $results;
	}

	/**
	 * Append attributes to query when building a query.
	 *
	 * @param array|string $attributes
	 * @return $this
	 */
	public function append($attributes)
	{
		$this->appends = array_unique(
			array_merge($this->appends, is_string($attributes) ? func_get_args() : $attributes)
		);

		return $this;
	}

	/**
	 * Set the accessors to append to model arrays.
	 *
	 * @param array $appends
	 * @return $this
	 */
	public function setAppends(array $appends)
	{
		$this->appends = $appends;

		return $this;
	}

	/**
	 * Get the mutated attributes for a given instance.
	 *
	 * @return array
	 */
	public function getMutatedAttributes()
	{
		$class = static::class;

		if (!isset(static::$mutatorCache[$class])) {
			static::cacheMutatedAttributes($class);
		}

		return static::$mutatorCache[$class];
	}

	/**
	 * Extract and cache all the mutated attributes of a class.
	 *
	 * @param string $class
	 * @return void
	 */
	public static function cacheMutatedAttributes($class)
	{
		static::$mutatorCache[$class] = collect(static::getMutatorMethods($class))->map(function ($match) {
			return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match);
		})->all();
	}

	/**
	 * Get all of the attribute mutator methods.
	 *
	 * @param mixed $class
	 * @return array
	 */
	protected static function getMutatorMethods($class)
	{
		preg_match_all('/(?<=^|;)get([^;]+?)Attribute(;|$)/', implode(';', get_class_methods($class)), $matches);

		return $matches[1];
	}
}
